Event Loop
2025-11-27 11:41
在执行上下文与作用域中我们知道:
- JavaScript 是单线程的;
- 所有代码都必须在**执行栈(Call Stack)**中按顺序执行;
- 每次调用函数,就会创建一个执行上下文并 push 到栈顶
- 运行完则 pop 出栈 这很好理解 —— 同步代码永远是“一条路走到黑”。 但当我们写下:
JSsetTimeout(() => console.log("hello"), 0); console.log("world"); // world hello
这意味着 JavaScript 虽然只有一条线程却能同时处理异步任务而不阻塞主线程.
简单理解:同步代码进入执行栈,异步任务进入任务队列,Event Loop 负责把队列里的任务“送回”执行栈继续运行。 它不是一个具体的 API,也不是某个对象,而是 JavaScript 运行时(浏览器 / Node.js)为了调度任务而实现的机制, 接下来我们就沿着执行栈、任务队列、宏任务、微任务的顺序,彻底把 Event Loop 讲清楚。
Event Loop 是什么?
Event Loop 不是某个 API,而是浏览器(或 Node.js)为了处理异步任务而设计的调度机制,也就是说,JavaScript 不是“同时”执行任务,而是“排队”执行任务。
执行栈(Call Stack)
执行栈是 JavaScript 引擎运行同步代码的地方。 例如:
JSfunction foo() { console.log("foo"); } foo(); // push Global // push foo // 执行 foo // pop foo // 继续执行 Global
任务队列(Task Queue)
当异步 API(如 setTimeout、fetch、Promise)完成时,不会立即执行其回调,而是把回调放入任务队列。 Event Loop 会在:
- 当前执行栈清空后
- 任务队列不为空时 将任务 push 回执行栈执行。
宏任务(Macro Task)与微任务(Micro Task)
JavaScript 将异步任务分成两类:
- 宏任务 MacroTask 常见:
- setTimeout
- setInterval
- setImmediate(Node)
- I/O
- Script, 脚本本身
- 微任务 MicroTask 常见: - Promise.then - async/await(await 后续逻辑) - MutationObserver - queueMicrotask 它们两个中 微任务优先级高于宏任务。
Event Loop 的执行流程
Event Loop 是一个持续循环的过程,可以概括为以下步骤:
- 清空调用栈: 从头开始,Event Loop 首先执行调用栈(Call Stack)中的所有同步任务,直到栈清空。
- 执行微任务: 同步任务执行完毕后,Event Loop 会检查微任务队列。它会一次性、完整地执行并清空微任务队列中所有任务。在执行这些微任务的过程中,如果产生了新的微任务,它们也会被添加到队列末尾,并在本次循环中立即执行。
- UI 渲染(浏览器): 如果是浏览器环境,在微任务队列清空后,可能会进行一次必要的 UI 渲染(即绘制界面变化)。
- 执行宏任务: 完成 UI 渲染后,Event Loop 从宏任务队列中取出排在最前面的一个宏任务。
- 循环: 将这个宏任务的执行上下文推入调用栈,执行完毕后,调用栈再次清空。然后,Event Loop 回到第 2 步,继续检查和清空微任务队列。
因为整个脚本文件(Script)本身就是一个宏任务,所以 Event Loop 的第一次循环就是从执行这个初始宏任务开始的。
例子:
JSconsole.log("1. 脚本开始 - 这是第一个宏任务"); // 微任务 Promise.resolve().then(() => { console.log("3. Promise微任务 - 在第一个宏任务之后执行"); }); // 宏任务 setTimeout(() => { Promise.resolve().then(() => { console.log("5"); }); console.log("4"); }, 0); // 宏任务 setTimeout(() => { Promise.resolve().then(() => { console.log("7"); }); console.log("8"); }, 0); // 同步代码继续执行 console.log("2"); // 1 2 3 4 5 6 8 7
按照此例子就能看出, 每次只会执行一个宏任务,然后清空微任务队列,再执行宏任务,直到所有宏任务都执行完毕。
UI 渲染(浏览器环境)
UI 渲染不是微任务,而是事件循环中的一个独立阶段:
- 发生在微任务队列清空之后
- 浏览器以约 60fps 的频率决定是否渲染
requestAnimationFrame回调在渲染之前执行
为什么说实在微任务清空之后再去 UI 渲染呢,看例子:
HTML<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> </head> <style> .box { width: 100px; height: 100px; background-color: red; } </style> <body></body> </html> <script> //DOM渲染 let dom = document.createElement("div"); dom.className = "box"; console.log("createElement"); document.body.appendChild(dom); console.log("append Child"); //定时器 setTimeout(() => { alert("setTimeout 0"); }, 0); // Promise let p1 = new Promise((resolve, reject) => { console.log("p1 Promise executor"); setTimeout(resolve("result1"), 0); }).then((value) => { console.log("p1 Promise then:", value); alert("p1 then"); }); let p2 = Promise.resolve("result2").then((value) => { console.log("p2 Promise then:", value); alert("p2 then"); }); // 同步alert console.log("end"); alert("第一个script结束"); </script> <script> alert("元素出现了吗?"); </script>
能够发现首歌宏任务中微任务执行完之前, 元素是不会进行渲染, 当 alert("p2 then") 触发后, 元素才会进行渲染。所以可以得出结论, 宏任务中的微任务执行完之后, 才会开始渲染 UI。这么做的目的在于:
- 浏览器会尽量在宏任务执行完成后,微任务队列清空后,进行一次渲染。
- 原因:渲染是昂贵操作,如果每次 DOM 改变都立即渲染,会严重影响性能。
- 因此浏览器会延迟渲染到微任务完成之后,保证宏任务执行期间的 DOM 改动都可以一起批量渲染。
其中
document.body.appendChild(dom);是同步 DOM 操作, 执行立即生效于内存中的 DOM 树,但浏览器还没有绘制到屏幕上,之后微任务执行完之前不会立即渲染, 所以宏任务中的微任务执行完之后,才会开始渲染 UI。
但是注意:并不是“严格保证”,浏览器渲染策略是优化行为,不同浏览器可能有微调。可以理解为浏览器会在宏任务完成、微任务队列清空后,才安排渲染。
微任务的“饥饿”现象
微任务队列优先级高于宏任务,因此如果微任务不断生成新的微任务,就会出现“饥饿现象”:宏任务甚至 UI 渲染被“饿死”,一直等不到执行。 比如在上面的例子中添加下列代码:
JSfunction loop() { Promise.resolve().then(() => { console.log("微任务循环"); loop(); // 不断生成新的微任务 }); } loop(); // 宏任务和 UI 渲染几乎永远不会执行
可以得出结论:
- 微任务队列必须清空后才会执行下一个宏任务。
- 无限同步微任务 和 长期同步微任务 会阻塞宏任务和渲染,导致页面卡死。
requestAnimationFrame(rAF)与 UI 渲染关系
requestAnimationFrame(简称 rAF)是浏览器专门为高性能 UI 更新设计的 API,它与普通的微任务、宏任务有着明确且不同的时序关系
它既不属于宏任务,也不属于微任务,它位于事件循环的一个独立阶段:渲染前阶段(Before Render Phase)。
所以 requestAnimationFrame 更适合做动画:
- 大多数浏览器刷新率为 60Hz → 每 ~16.6ms 渲染一次
- rAF 回调只会在即将渲染这一帧之前执行
- 因此:
- 不会掉帧(避免多余计算)。
- 不会频繁触发(节能)
- 动画的时间间隔稳定
比如现在有一个需求是页面中展示长列表,如果同时渲染就会卡顿, 为了解决这个问题,可以使用
requestAnimationFrame加 分片:
JS// 假设这是你的渲染函数 function renderItem(item) { const div = document.createElement("div"); div.textContent = item; document.body.appendChild(div); } // 大列表数据 const bigList = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`); // 分片渲染函数 function renderChunk(list, index = 0) { let startTime = performance.now(); while (index < list.length) { renderItem(list[index]); index++; // 每帧最多执行 8ms if (performance.now() - startTime > 8) { break; } } if (index < list.length) { requestAnimationFrame(() => renderChunk(list, index)); } } // 开始渲染 renderChunk(bigList);
每帧尽量控制在 16ms 内渲染一半,留一半时间给浏览器绘制”,然后进行更新,从而实现大量数据的渲染。
最后
Event Loop 核心就是:一次只执行一个宏任务 → 清空微任务队列 → 渲染 UI → 下一个宏任务。 Event Loop 不执行代码。 Event Loop 只是负责把可执行的任务放入 CallStack。 真正执行任务的是 CallStack(执行栈)。
提示:即使写成 setTimeout(fn, 0),浏览器也不会立即执行它。
浏览器定时器会遵守一个叫 clamp minimum time(最小时间片) 的限制:
普通页面最小 4ms
除非是获得用户操作触发的事件回调(例如 click)
所以:
setTimeout(fn, 0)
console.log(1)
依然是 1 先打印。
如果你看到这里,谢谢你花时间阅读这篇小小的开篇文章。希望未来能与你在技术的旅途中有更多交流。